Skip to content

feat(cli): ca2a start, run the reference transport from a config file - #52

Open
Susanpdl wants to merge 3 commits into
agentrust-io:mainfrom
Susanpdl:feat/live-a2a-inbound-serving
Open

feat(cli): ca2a start, run the reference transport from a config file#52
Susanpdl wants to merge 3 commits into
agentrust-io:mainfrom
Susanpdl:feat/live-a2a-inbound-serving

Conversation

@Susanpdl

@Susanpdl Susanpdl commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Closes the ca2a start gap the transport spec listed as "Not yet: serving is via transport.server.serve".

What changed since the first review

This branch originally shipped its own Starlette JSON-RPC listener. #53 landed a reference HTTP transport, PeerNode, and the attestation handshake while this was open, which made that listener duplicate work and, worse, seal against a key nobody had appraised. So the branch is refit rather than rebased: the Starlette server, the [serve] extra, and the starlette/uvicorn/httpx dependencies are gone. What is left is the config-driven entry point.

What it does

ca2a start --config ca2a-config.yaml resolves a policy and an attestation provider from the config file, builds a PeerNode, and serves it with ca2a_runtime.transport.server. No new dependencies, since the reference transport is standard library only.

The wiring lives in ca2a_runtime.bootstrap (load_policy, select_provider, build_peer_node) so it is usable without the CLI, and so the CLI adds no capability the library did not already have. A program that already holds a Policy and a provider should keep constructing a PeerNode directly.

$ ca2a start --config examples/minimal/ca2a-config.yaml
note: software-only provider, callers appraise this channel key as assurance="none" and the seal carries no hardware guarantee
ca2a listening on 127.0.0.1:8443 (provider=software-only)

Decisions worth a look

Provider selection fails closed, following the rule in tee/software.py that a no-guarantee posture is never a silent fallback. auto refuses to start when no confidential-computing platform is detected instead of quietly picking software-only, and a named hardware provider whose device node is absent is a startup error rather than a failure at the first handshake. Software mode prints the assurance a caller will actually appraise, so an operator does not have to infer it.

enclave_private_key_hex is dropped. A PeerNode generates its own X25519 channel keypair and publishes the public half through the handshake, so a pre-shared key in a config file would mean sealing to something nobody appraised. That was the right model before #53 and the wrong one after it.

listen_addr now defaults to 127.0.0.1:8443 and must name a host explicitly. It was 0.0.0.0:8443 while nothing read it; now that something binds it, defaulting to every interface would put a peer on the network by omission. transport.server.serve already defaulted to loopback, so the two now agree. Bad addresses fail ca2a validate-config rather than at bind time, and bracketed IPv6 parses.

Claim boundary

Unchanged from #53. Software mode is assurance="none", and the seal is not bound to a hardware-verified measurement. ca2a start does not move that line, it just makes the existing software-mode path runnable from a config file. LIMITATIONS.md, ROADMAP.md, and the transport spec are updated to say exactly that.

Tests

237 pass, 3 skipped. New coverage in tests/unit/test_bootstrap.py (policy resolution, provider fail-closed behavior, and a config-built node serving a real HTTP call) and tests/unit/test_cli_start.py (what ca2a start binds, and the four ways it refuses to start). ruff, mypy, and bandit are clean.

Also smoke-tested against a real socket: the handshake endpoint returns the offer, an unknown path 404s, and a port conflict exits 1 with cannot bind 127.0.0.1:8443 instead of a traceback.

One thing for you

Running the suite rewrites examples/rejection-with-proof/chain.json and dag.json with fresh keys, since the test executes demo.py and the demo generates keypairs. I reverted the churn here, but it will bite anyone who runs pytest before committing. Happy to open a separate issue.

Susanpdl added 2 commits July 19, 2026 10:02
Wire parse_peer_request into handle_peer_request on message/send so a
callee can enforce a PeerRequest on a live inbound call. Keep the claim
boundary: no attestation handshake and no seal-to-verified-measurement
binding; sealed open uses a configured software key only.

Signed-off-by: Susan Poudel <susanpdl77@gmail.com>
Require an explicit host in listen_addr instead of defaulting to
0.0.0.0, which tripped the hardcoded-bind-all-interfaces scan.

Signed-off-by: Susan Poudel <susanpdl77@gmail.com>
imran-siddique
imran-siddique previously approved these changes Jul 19, 2026

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the diff and thread. Scope is tight and matches the body: live inbound A2A JSON-RPC wiring for the second Tier 2 checkbox, with ordinary A2A returning ca2a: null rather than inventing a trust state, and malformed metadata plus denials failing closed with a structured ca2a_code. X25519 key handling validates length and is software-configured only, with the docs correctly caveating that it is not measurement-bound. No secrets in the diff; test keys are generated at runtime. Tests cover config, policy loader, and server paths, and all real checks are green. LGTM. Leaving the merge to a maintainer.

@imran-siddique

Copy link
Copy Markdown
Contributor

@Susanpdl this is approved and ready except for a merge conflict with main. Could you merge main into feat/live-a2a-inbound-serving (or rebase) and resolve the conflict? Once it is clean it can go in. Thanks.

@Susanpdl

Copy link
Copy Markdown
Contributor Author

Happy to resolve the conflict, but I want to check the shape with you first, because #53 landed after your approval here and it overlaps with this branch.

The conflict itself is small: CHANGELOG.md, LIMITATIONS.md, ROADMAP.md, docs/spec/transport.md, and pyproject.toml. No code conflicts.

The bigger question is duplication. #53 added transport/server.py, node.py, and the attestation handshake, so there is already a live HTTP serving path on main. This branch adds a second one (ca2a_runtime/server.py, a Starlette JSON-RPC listener for message/send), and it pulls in starlette and uvicorn, which cuts against the standard-library-only choice you made in #53.

What is still missing on main and is the part of this PR I think is worth keeping:

  • ca2a start. The CLI only has validate-config, verify-chain, and verify-dag, so there is no command to actually run a peer.
  • Config-driven policy: local_policy plus loading Cedar from policy_bundle_path, and listen_addr actually being consumed.
  • An optional configured enclave key for opening a sealed payload.

So my suggestion is to refit this branch instead of merging it as-is: keep ca2a start and the config/policy loading, but have it build a PeerNode and serve through transport.server.serve(), then drop my server.py and the [serve] extra. That removes the duplicate server, adds no new dependencies, and the CLI path inherits your handshake rather than using a raw configured key.

Want me to go that way, or would you rather keep the two transports separate (JSON-RPC alongside the reference server), or just close this and open a smaller CLI-only PR? Happy to do whichever you prefer.

…port

Merge main and refit this branch onto the transport that landed in agentrust-io#53.
The JSON-RPC listener here duplicated transport.server, so it is gone
along with the starlette/uvicorn [serve] extra. ca2a start now resolves a
policy and a provider from the config file, builds a PeerNode, and serves
it with transport.server, which is standard library only. The wiring
lives in ca2a_runtime.bootstrap so the CLI adds no capability the library
did not already have.

Provider selection fails closed. software-only carries no hardware
guarantee, so auto refuses to start when no confidential-computing
platform is detected rather than silently choosing it, and a named
hardware provider whose device node is absent is a startup error.

Drop enclave_private_key_hex: a PeerNode generates its own channel
keypair and publishes it through the handshake, so a pre-shared key in a
config file would be sealing to something nobody appraised. Default
listen_addr to 127.0.0.1:8443, matching transport.server.serve, now that
something actually binds it.

Signed-off-by: Susan Poudel <susanpdl77@gmail.com>
@Susanpdl Susanpdl changed the title feat(runtime): live A2A JSON-RPC listener via ca2a start feat(cli): ca2a start, run the reference transport from a config file Jul 28, 2026
@Susanpdl

Copy link
Copy Markdown
Contributor Author

@carloshvp refit onto #53, ready for another look.

Short version: your reference transport made my Starlette listener redundant, so I deleted it rather than shipping two servers. The [serve] extra and the starlette/uvicorn/httpx deps are gone with it. What is left is ca2a start, which reads a config file, resolves a policy and a provider, builds a PeerNode, and hands it to transport.server.serve. That fills the row your transport spec listed as "Not yet: serving is via transport.server.serve".

Three judgment calls I would like you to check:

  1. auto now refuses to start when it detects no confidential-computing platform, instead of falling back to software-only. That follows the comment in tee/software.py about a no-guarantee posture never being a silent fallback, but it does mean the default config no longer starts a listener without an edit. Tell me if you would rather it warned and continued.

  2. I dropped enclave_private_key_hex. Your PeerNode generates and attests its own channel key, so a pre-shared key in a config file would be sealing to something nobody appraised.

  3. listen_addr now defaults to 127.0.0.1:8443 and requires an explicit host. It was 0.0.0.0:8443 while nothing read it, and serve() already defaulted to loopback, so this makes them agree.

Claim boundary is unchanged: software mode is assurance="none" and the seal is still not bound to a hardware-verified measurement. The CLI just makes the existing path runnable from a config file, and LIMITATIONS/ROADMAP/transport.md say so.

237 tests pass, ruff/mypy/bandit clean, and I smoke-tested it against a real socket.

Unrelated, but worth knowing: running pytest rewrites examples/rejection-with-proof/chain.json and dag.json, because the test runs demo.py and the demo generates fresh keypairs. I reverted that churn out of this PR. Want me to open an issue?

@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

ℹ️ You can also turn on project coverage checks and project coverage reporting on Pull Request comment

Thanks for integrating Codecov - We've got you covered ☂️

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. All real checks pass on every platform and Python version; the only red is the maintainer-hold gate.

Reviewed the substance rather than the diffstat, and the two decisions that mattered are both right:

Provider selection fails closed. auto never degrades to software-only, and a named hardware provider whose device node is absent is a startup error rather than a silent downgrade. That is the same posture the rest of this stack holds to, and it is the difference between a software-mode listener being a stated configuration and being an accident.

Loopback default. Changing listen_addr from 0.0.0.0:8443 to 127.0.0.1:8443 was the important call, and you made it unprompted with the right reasoning: the old value was harmless while nothing read it, and the moment ca2a start binds it, defaulting to every interface would put a peer on the network by omission. Requiring the host explicitly is better than a permissive default with a warning.

Also good: load_policy refusing to start without one of policy_bundle_path or local_policy, since a node with no policy intersects every delegated scope to nothing and that reads as misconfiguration rather than a decision. Relative-path resolution against the config directory, the empty-file check, and OSError wrapped into ConfigError are all the right level of care for a config path.

Test coverage is proportionate (274 lines of tests for 216 of source), and the CHANGELOG is honest about what this does not do, including that attestation remains one-directional and software mode is still the default posture.

Leaving the merge to @imran-siddique.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants